iT邦幫忙

2024 iThome 鐵人賽

0
JavaScript

Signal API in Angular系列 第 38

Day 38 - 在 Angular 19 中重置或設定 LinkedSignal 中的值

  • 分享至 

  • xImage
  •  

Angular 19 中引入的新 LinkedSignal 功能透過允許訊號 (signal) 直接連結到來源值,提供了管理反應狀態 (reactive state) 的強大機制。 LinkedSignal 創造一個 WritableSignal;因此,開發人員可以明確設定該值或在來源變更時更新該值,從而促進兩者之間的無縫同步。

這篇部落格文章透過四個範例來展示 LinkedSignal 的功能。

  • LinkedSignal 的來源是頁碼,當頁碼變更時,值也更新。當來源超過最大數量時,LinkedSignal 將恢復為先前的值。
  • LinkedSignal 有一個簡寫版本,它根據來源傳回值。此外,我們可以獨立設定和更新來源和LinkedSignal。
  • LinkedSignal 的來源是數字 array。當來源變更為不同的 array 且該值不存在時,LinkeSignal 重設為 array 的第一個元素。
  • 最後一個 demo 是第三個 demo 的重寫,其中 store 封裝了LinkedSignal。 由於 LinkedSignal 是一個 WritableSignal,因此它可以透過呼叫 asReadOnly 方法傳回一個 Signal,並將其傳回給元件進行顯示。

示範 1:建立具有來源 (source) 和運算 (computation) 的 LinkedSignal

<div>
      <button (click)="pageNumber.set(1)">First</button>
      <button (click)="changePageNumber(-1)">Prev</button>    
      <button (click)="changePageNumber(1)">Next</button>
      <button (click)="pageNumber.set(200)">Last</button>
      <p>Go to: <input type="number" [(ngModel)]="pageNumber" /></p>
</div>
    <p>Page Number: {{ pageNumber() }}</p>
    <p>Current Page Number {{ currentPageNumber() }}</p>
    <p>Percentage of completion: {{ percentageOfCompletion() }}</p>

The template has four buttons that set the pageNumber signal to 1, decrease the signal by 1, increase the signal by 1, and set the signal to 200. The number input directly writes the value to the same signal. The template also displays the value of the pageNumber signal, currentPageNumber linked signal, and the percentableOfCompletion computed signal.

此範本有四個按鈕,分別將 pageNumber 訊號設定為 1、將訊號減少 1、將訊號增加 1 以及將訊號設定為 200。 Number input field 直接將值寫入 pageNumber 訊號。 此範本還顯示 pageNumber 訊號、currentPageNumber LinkedSignal 和 percentageOfCompletion 計算訊號的值。

// pagination.component.ts

import { ChangeDetectionStrategy, Component, computed, linkedSignal, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-pagination',
  standalone: true,
  imports: [FormsModule],
  templateUrl: 
  template: `...the inline template…`
  changeDetection: ChangeDetectionStrategy.OnPush
})
export default class PaginationComponent {
   pageNumber = signal(1)

  currentPageNumber = linkedSignal<number, number>({ 
    source: this.pageNumber,
    computation: (pageNumber, previous) => {
      if (!previous) {
        return pageNumber;
      }

      return (pageNumber < 1 || pageNumber > 200) ? previous.value : pageNumber
    }
  });

  percentageOfCompletion = computed(() => `${((this.currentPageNumber() * 1.0) * 100 / 200).toFixed(2)}%`);

  changePageNumber(offset: number) {
    this.pageNumber.update((value) => Math.max(1, Math.min(200, value + offset)));
  }
}

currentPageNumber 的來源是 pageNumber 訊號,當來源變更時,該訊號會計算新值。computation 屬性是一個接受頁碼和 previous 物件的函數。當 previous 物件未定義時,LinkedSignal 傳回頁碼。當頁碼超出範圍時,LinkedSignal 將傳回上一個頁碼或 previous.value

在示範中,我可以輸入 201 將值綁定到 pageNumber 訊號,但 currentPageNumber 恢復為先前的值。

此外,計算訊號可以從 LinkedSignal 衍生,因為它也是 WritableSignal。 percentageOfCompetation 計算訊號源自 currentPageNumber LinkedSignal,用於計算百分比並將其轉換為字串。

示範 2:建立 LinkedSignal 的簡寫版本

<h2>Update the shorthand version of the linked signal. Set and update the signal</h2>
<p>Update country: <input [(ngModel)]="country" /></p>
<p>Update favorite country: <input [(ngModel)]="favoriteCountry" /></p>
<button (click)="country.set('United States of America')">Reset</button>
<button (click)="changeCountry()">Update source and linked signal</button>
<p>Country: {{ country() }}</p>
<p>Favorite Country: {{ favoriteCountry() }}</p>
<p>Reversed Country: {{ reversedFavoriteCountry() }}</p>

此模範本兩個 HTML input 元素。 第一個 input field 綁定到 country 訊號,而第二個 input field 綁定到 favoriteCountry LinkedSignal。 當按鈕重置 country 訊號時,favoriteCountry 也會重置。另一個按鈕呼叫 changeCountry 函數直接寫入 country 訊號和 favoriteCountry LinkedSignal。 然後,我們顯示訊號以查看每個操作後的不同值。

// favorite-country.component.ts

import { ChangeDetectionStrategy, Component, computed, linkedSignal, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-favorite-country',
  standalone: true,
  imports: [FormsModule],
  template: `... inline template…`,
  styles: ``,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export default class FavoriteCountryComponent {
  country = signal('United States of America')

  favoriteCountry = linkedSignal(() => this.country());
  reversedFavoriteCountry = computed(() => this.favoriteCountry().split('').toReversed().join(''));

  changeCountry() {
    this.country.set('Canada');
    this.favoriteCountry.update((c) => c.toUpperCase());
  }
}
favoriteCountry = linkedSignal(() => this.country());

這是 LinkedSignal 的簡寫形式,傳回 country 訊號的值。 當我在第一個 HTML input 中輸入不同的國家時,countryfavoriteCountry 都會更新。此外,第二個 HTML input 顯示 favoriteCountry 的最新值。 當我在第二個 HTML input中輸入不同的國家時,僅更新 favoriteCountrycountry不受影響。此時,LinkedSignal 和來源持有不同的值。 在這兩種情況下,reversedFavoriteCountry 以相反的順序顯示 favoriteCountry。 當我單擊按鈕呼叫 changeCountry 方法時,我將 country 訊號設定為 "Canada" 並觸發favoriteCountry LinkedSignal 進行更新。 LinkedSignal 也是 WritableSignal;我可以呼叫 update 方法將 favoriteCountry 訊號轉換為大寫。 因此,country 的值是 "Canada",favoriteCountry 是 "CANADA", reversedFavoriteCountry是 "ADNAC"。

示範 3:當來源 array 變更時重置/保留元素

<h2>Reset linked signal after updating source</h2>
<p>Source: {{ shoeSizes() }}</p>
<p>Shoe size: {{ currentShoeSize() }}</p>
<p>Shoe index: {{ index() }}</p>
<div>
   <button (click)="changeShoeSizes()">Update shoe size source</button>
   <button (click)="updateLargestSize()">Set to the largest size</button>
</div>
<label for="shoeSize">
   <span>Choose a shoe size: </span>
   <select id="shoeSize" name="shoeSize" [(ngModel)]="currentShoeSize">
       @for (size of shoeSizes(); track size) {
          <option [ngValue]="size">{{ size }}</option>
        }
   </select>
</label>

此範本顯示 LinkedSignal 的來源,它是數字 array。 currentShoeSize LinkedSignal 顯示 array 中選定的元素。 index 計算訊號衍生 currentShoeSize 在來源中的索引。 第一個按鈕呼叫 changeShoeSizes 方法來更新來源並導致 currentShoeSize 設定或重置值。 updateLargeSizes 方法將 currentShoeSize LinkedSignal 設定為 array 的最後一個元素。 最後,範本填入下拉清單以選擇要寫入 currentShoeSize LinkedSignal 的值。

import { ChangeDetectionStrategy, Component, computed, linkedSignal, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';

const SHOE_SIZES = [5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10];
const SHOE_SIZES2 = [4, 5, 6, 7, 8, 9, 10, 11, 12]

@Component({
  selector: 'app-shoe-sizes',
  standalone: true,
  imports: [FormsModule],
  template: `...inline template…`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export default class ShoeSizesComponent {
  shoeSizes = signal(SHOE_SIZES);
  currentShoeSize = linkedSignal<number[], number>({
    source: this.shoeSizes,
    computation: (options, previous) => { 
      if (!previous) {
        return options[0];        
      }

      return options.includes(previous.value) ? previous.value : options[0]; 
    }
  });

  index = computed(() => this.shoeSizes().indexOf(this.currentShoeSize()));

  changeShoeSizes() {
    if (this.shoeSizes()[0] === SHOE_SIZES2[0]) {
      this.shoeSizes.set(SHOE_SIZES);
    } else {
      this.shoeSizes.set(SHOE_SIZES2);
    }
  }

  updateLargestSize() {
    const largestSize = this.shoeSizes().at(-1);
    if (typeof largestSize !== 'undefined') {
      this.currentShoeSize.set(largestSize); 
    }
  }
}

該示範的重要部分是在呼叫 changeShoeSizes 方法後重置 currentShoeSize。 此方法在 [5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10][4, 5, 6, 7, 8, 9, 10, 11, 12] 之間切換 shoesSizes 信號並在此過程中更新來源。 然後,currentShoeSizes LinkedSignal 使用 computation 函數來計算新值。

computation: (options, previous) => { 
      if (!previous) {
        return options[0];        
      }

      return options.includes(previous.value) ? previous.value : options[0]; 
}

如果 previous 物件未定義 (undefined),則傳回 array 的第一個元素。 如果新 array 中也存在前一個值,則傳回該值。否則,該函數會傳回 array 的第一個元素。

例如,來源為 [4, 5, 6, 7, 8, 9, 10, 11, 12]currentShoeSize 為 10。 如果新來源變成 [5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9,5, 10],則該值不會重置,因為找到了 10。 如果 currentShoeSize 是 12,則在 array 中找不到它,computation 函數會將值重設為 5,這是 array的第一個元素。

示範 4:將 LinkedSignal 封裝在 store 中

// shoe-sizes.store.ts

import { linkedSignal, signal } from '@angular/core';

const SHOE_SIZES = [5, 5.5, 6, 6.5, 7, 7.5, 8, 8.5, 9, 9.5, 10];
const SHOE_SIZES2 = [4, 5, 6, 7, 8, 9, 10, 11, 12];

const _shoeSizes = signal(SHOE_SIZES);
const _currentShoeSize = linkedSignal<number[], number>({
  source: _shoeSizes,
  computation: (options, previous) => { 
    if (!previous) {
      // reset to the first size
      return options[0];        
    }

    return options.includes(previous.value) ? previous.value : options[0];
  }
});

export const ShoeSizesStore = {
  shoeSizes: _shoeSizes.asReadonly(),
  currentShoeSize: _currentShoeSize.asReadonly(),
  updateShoeSize(value: number) {
    _currentShoeSize.set(value);
  },
  changeShoeSizes() {
    if (_shoeSizes()[0] === SHOE_SIZES2[0]) {
      _shoeSizes.set(SHOE_SIZES);
    } else {
      _shoeSizes.set(SHOE_SIZES2);
    }
  },
  updateLargestSize() {
    const largestSize = _shoeSizes().at(-1);
    if (typeof largestSize !== 'undefined') {
      this.updateShoeSize(largestSize);
    }
  }
}
// shoe-sizes-store.component.ts

import { ShoeSizesStore } from '../stores';

@Component({
  selector: 'app-shoe-sizes-store',
  standalone: true,
  imports: [FormsModule],
  template: `
    <p>Source: {{ shoeSizes() }}</p>
    <p>Shoe size: {{ currentShoeSize() }}</p>
    <p>Shoe index: {{ index() }}</p>
    <div>
      <button (click)="changeShoeSizes()">Update shoe size source</button>
      <button (click)="updateLargestSize()">Set to the largest size</button>
    </div>
    <label for="shoeSize">
      <span>Choose a shoe size: </span>
      <select id="shoeSize" name="shoeSize" [ngModel]="currentShoeSize()" (ngModelChange)="updateShoeSize($event)">
        @for (size of shoeSizes(); track size) {
          <option [ngValue]="size">{{ size }}</option>
        }
      </select>
    </label>
  `,
})
export default class ShoeSizesStoreComponent {
  currentShoeSize = ShoeSizesStore.currentShoeSize;
  shoeSizes = ShoeSizesStore.shoeSizes;

  index = computed(() => this.shoeSizes().indexOf(this.currentShoeSize()));

  constructor() {
    this.updateShoeSize(5);
  }

  updateShoeSize(value: number) {
    ShoeSizesStore.updateShoeSize(value);
  }

  changeShoeSizes = ShoeSizesStore.changeShoeSizes;
  updateLargestSize = ShoeSizesStore.updateLargestSize;
}

我將元件的 LinkedSignal 邏輯移至 store。此元件的 constructor 將 LinkedSignal 的值設為 5。
另一個修改是將 [(ngModel)] 分解為 [ngModel] 和 ngModelChange 事件發射器 (event emitter)。 這是因為 currentShoeSize 是唯讀的,我必須呼叫 updateShoeSize 方法來更新 store 中的 #currentShoeSize LinkedSignal。

結論:

  • LinkedSignal 有一個來源,可以觸發 computation 函數來設定或重置值。
  • computation 函數接受來源和 previous 物件。 它可以使用這兩個參數來執行邏輯以傳回下一個值。
  • LinkedSignal 是一個 WritableSignal,可以設定和更新值並傳回唯讀訊號 (read-only signa)。
  • LinkedSignal 可以具有與來源不同的值,因為開發人員可以直接為其寫入值。

鐵人賽的第 38 天到此結束

參考:


上一篇
Day 37 - 在信號中更新 Map,我希望有人在我犯錯之前告訴我。
下一篇
Day 39 - 使用 Angular 19 中的 Resource API 進行資料檢索
系列文
Signal API in Angular39
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言